Skip to content

editor: Align selections by display position instead of byte column - #61997

Merged
SomeoneToIgnore merged 4 commits into
zed-industries:mainfrom
somtri:editor-align-multibyte-chars
Aug 14, 2026
Merged

editor: Align selections by display position instead of byte column#61997
SomeoneToIgnore merged 4 commits into
zed-industries:mainfrom
somtri:editor-align-multibyte-chars

Conversation

@somtri

@somtri somtri commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Objective

Fixes #60192
Closes #62308

editor: align selection lines cursors up by their buffer column, and that column counts bytes. If a multi-byte character sits before the cursor, the byte column is larger than the position the cursor is actually drawn at, so the row gets padded with the wrong number of spaces.

The issue reports it with (3 bytes) and π (2 bytes):

a ← 1  # one
bc ← π  # two

Put a cursor on each #, run the action, and the result is still misaligned:

a ← 1    # one
bc ← π  # two

This is not the columnar selection bug fixed in #57097. That one was select_columns in selection.rs, where the output is a selection range. This one is align_selections in editor.rs, where the output is inserted spaces, so the same byte-column assumption was left behind in a second place, and fixing it here needs a rounding step that the first fix did not.

Solution

Measure each cursor by its x offset in the laid-out display row (DisplaySnapshot::x_for_display_point), take the target for a column as the furthest x across the rows, then turn the difference into whole spaces by dividing by the advance width of ' '. The offset that carries into later columns becomes an x offset instead of a column count.

The display map has already expanded tabs by the time the row is laid out, so a leading tab now counts as its expanded width instead of as a single byte.

Two things I would look at first in review:

  • The division rounds instead of truncating. The x offsets are built by repeated float addition, so a gap that should be exactly three spaces can arrive as 2.9999998, and truncating inserts two.
  • The function returns early if the space advance is missing or zero. Dividing by zero gives inf, which saturates to a huge u32 and then tries to allocate that many spaces.

I did not add any public items and did not touch selection.rs.

Testing

cargo test -p editor align on Windows: 6 passed, 0 failed. That is the new test plus the two existing align_selections tests, which I did not change and which still pass.

test_align_selections_with_multibyte_chars covers the repro from the issue, a second column whose offset has to carry past a multi-byte character in the first, a leading tab, a non-BMP character, and a case where multi-byte characters sit after the cursors and nothing should move.

I also checked that the test catches the bug rather than just passing: reverting the change in editor.rs and keeping the test makes it fail on the repro, inserting four spaces where three are right. Putting the change back makes it pass. The two older align tests pass either way, since they are pure ASCII.

What I have not covered:

  • Wide CJK characters, combining marks, and ZWJ clusters. These should be right by construction, since the code measures advances rather than counting characters, but I have no tests for them. The headless text system behind gpui::test gives every BMP character the same advance, so a test there would assert the test double's behavior rather than the real renderer's.
  • Proportional fonts. Aligning with inserted spaces cannot be exact when glyph widths vary. The code rounds to the nearest whole space.
  • Soft-wrapped rows. I measure x from the start of the wrapped row but still group cursors by buffer row, so two cursors on one buffer row that sit either side of a wrap boundary get measured from different origins, and the carried offset crosses that boundary as if they shared one. The old byte-column code did not have that particular failure. I left it alone because fixing it is a different change, but I would rather flag it than have you find it.
  • I work on Windows and have no macOS machine. The arithmetic is platform independent, so I do not expect a difference, but I have not checked.

To try it: paste the two lines from the issue, put a cursor on each # with editor: select next, then run editor: align selection. The two # should line up.

Self-Review Checklist:

  • I've reviewed my own diff for quality, security, and reliability
  • Unsafe blocks (if any) have justifying comments
  • The content adheres to Zed's UI standards (UX/UI and icon guidelines)
  • Tests cover the new/changed behavior
  • Performance impact has been considered and is acceptable

Release Notes:

  • Fixed editor: align selections misaligning rows and Vim ctrl-d / ctrl-u / ctrl-f leaving the cursor behind on lines with multi-byte characters or tabs.

`align_selections` padded rows based on the buffer column, which counts
bytes. A row with a multi-byte character before the cursor reported a
larger column than the position the cursor is drawn at, so it received
the wrong number of spaces.

Measure each cursor by its x offset in the laid-out display row and
convert the difference to whole spaces using the advance width of a
space. Because the display map has already expanded tabs by then, a
leading tab now counts as its expanded width rather than as one byte.
@cla-bot cla-bot Bot added the cla-signed The user has signed the Contributor License Agreement label Jul 31, 2026
@zed-community-bot zed-community-bot Bot added the first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions label Jul 31, 2026
@RemcoSmitsDev RemcoSmitsDev added the area:editor Feedback for code editing, formatting, editor iterations, etc label Jul 31, 2026

@SomeoneToIgnore SomeoneToIgnore left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you.

Had to rewrite the fix as it broke a lot:

  • Measured x per display row (x_for_display_point), so soft wrap reset the origin at each wrap boundary: junk spaces went into the wrapped line and the line needing padding was left alone — regressing pure-ASCII + soft-wrap cases that worked before (now covered by test_align_selections_with_soft_wrap)

  • Same display-space measurement counted inlay hints and folds, so permanent spaces would be inserted to compensate for ephemeral decorations

  • Rewrote the measurement to content space: tab-expanded buffer-line prefix laid out via layout_line — wrap/inlay/fold-independent, still fixes multi-byte, tabs, and double-width glyphs; space-advance quantization kept as-is

  • As usual, LLM comments are bad, the original one instantly got stale and removed

@SomeoneToIgnore
SomeoneToIgnore added this pull request to the merge queue Aug 14, 2026
Merged via the queue into zed-industries:main with commit 0ad5441 Aug 14, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:editor Feedback for code editing, formatting, editor iterations, etc cla-signed The user has signed the Contributor License Agreement first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions

Projects

None yet

3 participants